Перейти к основному содержимому

5.11. Операторы и циклы в Ruby

Разработчику Архитектору

Операторы и циклы в Ruby

Ruby — язык программирования с динамической типизацией и объектно-ориентированной архитектурой, в которой всё является объектом, включая числа, логические значения и даже nil. Эта фундаментальная особенность определяет и подход к реализации операторов и управляющих конструкций: большинство операторов в Ruby — это методы, вызываемые у объектов. Такой дизайн обеспечивает высокую гибкость и расширяемость, но требует понимания внутренней семантики вызовов и приоритетов.

В данной главе рассматриваются:

  • операторы (арифметические, сравнения, логические, побитовые, присваивания и специальные),
  • условные конструкции (if, unless, case),
  • циклические конструкции (while, until, loop, итераторы),
  • связанные особенности: операторы and/or, nil-коалесценция, сокращённые формы, идиомы Ruby.

Все конструкции приводятся с учётом версии языка Ruby 3.x (актуальной на момент написания), однако основные принципы остаются неизменными начиная с Ruby 1.9.


1. Операторы

В Ruby оператор — это синтаксическая оболочка для вызова метода объекта. Например, выражение a + b интерпретируется как вызов метода + у объекта a с аргументом b: a.+(b). Эта особенность позволяет переопределять поведение операторов в пользовательских классах через переопределение соответствующих методов.

1.1. Арифметические операторы

Арифметические операторы применяются к числовым объектам (Integer, Float, Rational, Complex) и возвращают новый объект соответствующего типа.

ОператорОписаниеПримерЭквивалентный вызов
+Сложение5 + 385.+(3)
-Вычитание5 - 325.-(3)
*Умножение4 * 284.*(2)
/Деление7 / 23 (целочисленное деление для Integer)7./(2)
%Остаток от деления (по модулю)7 % 317.%(3) или 7.modulo(3)
**Возведение в степень2 ** 382.**(3)

Важные особенности:

  • Для целых чисел (Integer) операция / выполняет целочисленное деление с усечением к нулю (не к минус бесконечности, в отличие от математического деления по модулю). Например, -7 / 3-2.
  • Оператор % в Ruby реализует модульное арифметическое значение, и его поведение определяется методом modulo. В отличие от языков вроде C или Java, результат % в Ruby всегда имеет тот же знак, что и делитель. Например:
    • 7 % 31
    • 7 % -3-2
    • -7 % 32
    • -7 % -3-1
      Это соответствует определению a.modulo(b) = a - b * (a.div(b)), где div — целочисленное деление с округлением вниз (floor division).
  • Оператор ** может принимать дробные и отрицательные показатели степени, возвращая Float при нецелом результате: 4 ** 0.52.0, 2 ** -10.5.

Переполнение целых чисел в Ruby не приводит к ошибке: Integer имеет неограниченную точность (arbitrary-precision integer, как BigInt в других языках). Например, 2 ** 1000 вычисляется без потери точности.

1.2. Операторы сравнения

Операторы сравнения возвращают объекты класса TrueClass, FalseClass или nil. В Ruby только два значения считаются ложными в булевом контексте — false и nil; всё остальное (включая 0, "", []) — истинное.

ОператорОписаниеПример
==Равенство значений5 == 5.0true
!=Неравенство3 != 4true
<, >, <=, >=Упорядоченное сравнение"apple" < "banana"true
<=>Оператор «космический корабль» (spaceship) — трёхстороннее сравнение5 <=> 31, 3 <=> 5-1, 4 <=> 4.00, 1 <=> "1"nil
===Оператор «case equality» — используется в case-выражениях, семантика зависит от левого операндаInteger === 42true, /a/ === "bar"true, Range === 5 — для 1..10 === 5true

Пояснения:

  • Оператор == вызывает метод ==, который по умолчанию проверяет равенство объектов по значению (а не по ссылке, в отличие от equal?). Он может быть переопределён — например, в String учитывает содержимое, регистр и кодировку.
  • Оператор <=> должен возвращать -1, 0, 1 или nil. Если сравнение бессмысленно (например, число и строка), возвращается nil. При реализации пользовательских классов определение <=> позволяет легко подключить миксин Comparable, который автоматически предоставляет <, <=, >, >= и between?.
  • Оператор === не симметричен. Его поведение зависит от класса левого операнда:
    • Для классов (Class): Class === obj эквивалентен obj.is_a?(Class).
    • Для диапазонов (Range): range === value эквивалентен range.cover?(value).
    • Для регулярных выражений (Regexp): /pattern/ === str эквивалентен str =~ /pattern/ (возвращает true, если совпадение найдено).
    • Для простых значений (String, Symbol, Integer и др.) поведение совпадает с ==, но это соглашение, а не требование.

1.3. Логические операторы

Ruby предоставляет два уровня логических операторов: низкоприоритетные (and, or, not) и высокоприоритетные (&&, ||, !). Разница — в приоритете относительно других операторов (в первую очередь =).

ОператорПриоритетАналог в других языкахОсобенности
&&Высокий&&Чаще используется в выражениях
``Высокий
!Высокий!Унарный оператор логического отрицания
andНизкийРедко используется; иногда применяется для потокового управления (например, do_something and return)
orНизкийАналогично and
notНизкийУнарный, редко используется: not truefalse

Короткое замыкание (short-circuit evaluation):

  • В a && b: если a ложно (false или nil), b не вычисляется.
  • В a || b: если a истинно (не false и не nil), b не вычисляется.

Примеры:

x = nil
y = x && x.length # → nil (x ложно, x.length не вызывается)
z = x || "default" # → "default"
w = false || true # → true

Поскольку в Ruby только false и nil ложны, логические операторы часто используются для установки значений по умолчанию или защиты от nil.

1.4. Операторы присваивания

Ruby поддерживает как простое присваивание, так и составные формы.

  • = — простое присваивание. Создаёт локальную переменную (если её нет), не вызывает метод.
    Пример: x = 10. Локальная переменная создаётся в момент анализа кода, даже если присваивание не выполнится (например, в ветке if false).

  • +=, -=, *=, /=, %=, **= — составные операторы присваивания.
    Выражение a += b эквивалентно a = a + b, то есть вызывает метод + у a, затем присваивает результат переменной a.
    Важно: это не модификация объекта на месте (если только + не реализован как +=), а создание нового объекта и перепривязка переменной.

  • ||= — оператор условного присваивания («nil-coalescing assignment»).
    x ||= y эквивалентно x = x || y, но с семантикой: присвоить y, только если x ложно (nil или false).
    Часто используется для кэширования:

    def expensive_value
    @expensive_value ||= compute_expensive_value
    end
  • &&= — аналогично: x &&= yx = x && y. Присваивает y, только если x истинно.

  • = в контексте параллельного присваивания:

    a, b = 1, 2           # → a = 1, b = 2
    a, b = [1, 2] # → a = 1, b = 2 (распаковка массива)
    a, *rest = [1, 2, 3] # → a = 1, rest = [2, 3]

Особенность: в Ruby нет операторов инкремента/декремента (++, --). Вместо них используются += 1 или методы succ/next (для Integer, String и др.):
x = 5; x = x.succ6; "a".succ"b".

1.5. Побитовые операторы

Доступны для целых чисел. Все побитовые операторы — методы класса Integer.

ОператорОписаниеПример
&Побитовое И5 & 31 (0b101 & 0b011 = 0b001)
|Побитовое ИЛИ5 | 37 (0b101 | 0b011 = 0b111)
^Побитовое исключающее ИЛИ5 ^ 36
~Побитовое НЕ (унарный)~5-6 (дополнение до -1 по правилам двух’s complement)
<<Арифметический сдвиг влево2 << 316 (умножение на 2³)
>>Арифметический сдвиг вправо16 >> 24

Замечание: сдвиги работают с отрицательными числами корректно: -8 >> 1-4.

1.6. Специальные операторы

  • .. и ... — операторы диапазонов:

    • a..b — включает b («замкнутый» диапазон),
    • a...b — исключает b («полуоткрытый» диапазон). Оба создают объекты класса Range. Пример: (1..5).to_a[1, 2, 3, 4, 5]; (1...5).to_a[1, 2, 3, 4].
  • ? : — тернарный условный оператор:
    условие ? значение_если_истина : значение_если_ложь.
    Пример: x > 0 ? "positive" : "non-positive".

  • defined? — унарный оператор (на самом деле — метод верхнего уровня), возвращающий строку с типом выражения или nil, если выражение не определено:

    defined?(x)        # → nil (если x не определена)
    x = 1
    defined?(x) # → "local-variable"
    defined?(Math::PI) # → "constant"
    defined?(1 + 1) # → "expression"
  • => — используется в хэш-литералах (до Ruby 1.9) и в case/when, но не является самостоятельным оператором в выражениях.


2. Условные конструкции

2.1. if, elsif, else

Конструкция if — основной способ ветвления. В Ruby if — это выражение, а не оператор, то есть возвращает значение последнего вычисленного выражения в выбранной ветке.

Синтаксис:

if условие1
# блок 1
elsif условие2
# блок 2
else
# блок else
end

Особенности:

  • Условие может быть любым объектом. Ложными считаются только false и nil.
  • Отсутствие фигурных скобок: блоки определяются ключевыми словами и отступами (по соглашению).
  • Возврат значения:
    result = if x > 0
    "positive"
    elsif x < 0
    "negative"
    else
    "zero"
    end

Модификаторы if и unless (постфиксная форма):

puts "x положительное" if x > 0
raise "ошибка" unless valid?

Удобны для однострочных проверок. Приоритет ниже, чем у присваивания:
x = y if condition(x = y) if condition, а не x = (y if condition).

2.2. unless

Эквивалент if not, но читается естественнее при формулировке исключений:

unless user.admin?
redirect_to root_path
end

Поддерживает else, но не поддерживает elsif (для цепочек лучше использовать if).

2.3. case

Многоальтернативная условная конструкция. Имеет две формы.

Форма 1: без аргумента у case — как if/elsif:

case
when x < 0
"отрицательное"
when x == 0
"ноль"
else
"положительное"
end

Форма 2: с аргументом у case — использует оператор === для сравнения:

case x
when Integer
"целое"
when String
"строка"
when 1..10
"от 1 до 10"
when /foo/
"содержит 'foo'"
else
"неизвестно"
end

Здесь Integer === x, String === x, (1..10) === x, /foo/ === x — именно поэтому === так важен.

Возврат значения: как и if, case возвращает значение последнего выражения в сработавшей ветке.


3. Циклические конструкции

В Ruby существует несколько способов организации повторяющихся вычислений: традиционные управляющие конструкции (while, until, loop), а также итераторы — методы, принимающие блоки кода. Последние составляют основу идиоматического стиля Ruby и предпочтительны в большинстве случаев, поскольку обеспечивают лучшую читаемость, инкапсуляцию логики и устойчивость к ошибкам (например, забытому изменению счётчика).

3.1. while и until

Конструкции while и until синтаксически схожи и отличаются только условием продолжения.

Синтаксис while:

while условие
# тело цикла
end

Цикл выполняется, пока условие истинно (не false и не nil). Проверка условия происходит перед каждой итерацией. Если условие изначально ложно, тело цикла не выполнится ни разу.

Синтаксис until:

until условие
# тело цикла
end

Цикл выполняется, пока условие ложно. Эквивалентно while !(условие), но выразительнее в случаях вроде «пока пользователь не ввёл корректные данные».

Модификаторы while и until (постфиксная форма):

x = 0
x += 1 while x < 10

Обратите внимание: в постфиксной форме тело цикла выполняется хотя бы один раз, даже если условие изначально ложно, — но только если тело представляет собой одно выражение. В случае составного тела (через begin/end) поведение меняется.

Особый случай: begin/end while

begin
puts "Введите число:"
input = gets.chomp
end while input.empty?

Такая форма гарантирует, что тело выполнится минимум один раз — аналог do/while в C-подобных языках. Однако в Ruby 3.0+ такая конструкция считается устаревшей и вызывает предупреждение (warning: (...) while (...) without rescue is a syntax error в будущем), поэтому предпочтительно использовать loop + break.

3.2. loop

Бесконечный цикл, прерываемый явно через break, return или exit:

loop do
# тело
break if условие_выхода
end

Конструкция loop — единственная в Ruby, гарантированно реализованная как встроенная (не метод), что обеспечивает максимальную производительность. Она часто используется как основа для пользовательских циклов с нетривиальной логикой выхода.

Преимущества loop:

  • Чётко выражает намерение: «повторять бесконечно, пока не будет явного выхода»;
  • Позволяет использовать break с возвратом значения:
    result = loop do
    input = gets.chomp
    break input unless input.empty?
    end

3.3. Итераторы — основа циклических операций в Ruby

В Ruby итерация реализована через методы-итераторы, которые принимают блоки (block) — анонимные функции, ограниченные по времени жизни вызовом итератора. Эта модель позволяет абстрагироваться от деталей управления счётчиком, граничными условиями и инкрементом.

3.3.1. Integer#times

Повторяет блок заданное количество раз. Передаёт в блок текущий индекс (от 0 до n-1):

5.times { |i| puts "Итерация #{i}" }
# → 0, 1, 2, 3, 4

Эквивалентно for i in 0...5, но без создания переменной цикла в окружающей области видимости.

3.3.2. Integer#downto и Integer#upto

Генерируют последовательность целых чисел в указанном диапазоне:

5.downto(1) { |i| puts i }  # → 5, 4, 3, 2, 1
1.upto(5) { |i| puts i } # → 1, 2, 3, 4, 5

Работают и с нецелыми числами, но только если шаг равен 1 (иначе — ошибка). Для дробных шагов используются другие подходы.

3.3.3. Range#each

Перебирает все элементы диапазона. Реализован для числовых и символьных диапазонов:

('a'..'e').each { |c| print c }  # → abcde
(1..3).each { |n| puts n } # → 1, 2, 3

Важно: для больших или бесконечных диапазонов (1..Float::INFINITY) вызов each приведёт к зависанию — в таких случаях используют lazy (ленивые вычисления).

3.3.4. Array#each, Hash#each и другие коллекции

Стандартные коллекции предоставляют метод each, который применяет блок к каждому элементу:

[1, 2, 3].each { |x| puts x * 2 }     # → 2, 4, 6
{a: 1, b: 2}.each { |k, v| puts "#{k}=#{v}" } # → a=1, b=2

Поведение each определено протоколом Enumerable, который реализуется почти всеми стандартными коллекциями. При реализации пользовательских коллекций достаточно определить each, чтобы получить доступ к map, select, reduce и другим методам.

3.3.5. Контроль потока внутри блока: next, break, redo

Блоки поддерживают специальные ключевые слова для управления итерацией:

  • next — завершает текущую итерацию и переходит к следующей (аналог continue в C/Java). Может возвращать значение, передаваемое итератору:

    (1..5).map { |x| next 0 if x.even?; x }  # → [1, 0, 3, 0, 5]
  • break — прерывает итерацию и возвращает управление за пределы итератора. Может принимать значение, которое станет результатом всего выражения итератора:

    result = [1, 2, 3, 4, 5].each { |x| break x * 10 if x > 3 }
    # result → 40
  • redo — повторяет текущую итерацию с самого начала, не изменяя состояние итератора (например, не увеличивая счётчик). Опасен — может привести к бесконечному циклу при неосторожном использовании:

    count = 0
    3.times do |i|
    count += 1
    redo if count < 5
    puts "i=#{i}, count=#{count}"
    end
    # Выведет три строки, но count будет ≥5 к моменту вывода
3.3.6. Ленивые итераторы (lazy)

Для работы с потенциально бесконечными или очень большими последовательностями Ruby предоставляет ленивые вычисления через Enumerator::Lazy:

result = (1..Float::INFINITY)
.lazy
.select(&:even?)
.map { |x| x ** 2 }
.first(5)
# → [4, 16, 36, 64, 100]

Здесь цепочка методов (select, map) не выполняется сразу, а откладывается до вызова first, который запрашивает ровно пять элементов. Это позволяет эффективно обрабатывать потоки данных без избыточного потребления памяти.

3.4. for — устаревшая конструкция

Синтаксис:

for переменная in коллекция
# тело
end

Семантически эквивалентен collection.each { |переменная| ... }, но с важным отличием: переменная переменная создаётся в текущей области видимости, а не в локальной области блока. Это может привести к неожиданным эффектам перезаписи переменных.

Пример:

x = "внешняя"
for x in [1, 2, 3]
# ...
end
puts x # → 3 (переменная x перезаписана)

В идиоматическом Ruby конструкция for почти не используется — предпочтение отдаётся each.


4. Идиомы и лучшие практики

4.1. Предпочтение итераторов перед while/for

Итераторы:

  • исключают ошибки в управлении счётчиком («на единицу больше/меньше»);
  • обеспечивают локальность переменных;
  • позволяют легко комбинировать трансформации (map, select, reduce);
  • естественно интегрируются с функциональным стилем.

Пример «неправильного» цикла:

i = 0
result = []
while i < array.length
result << array[i] * 2 if array[i].even?
i += 1
end

Идиоматическая замена:

result = array.select(&:even?).map { |x| x * 2 }

4.2. Использование case вместо цепочек if/elsif

Когда проверяется одно и то же значение по нескольким условиям, case читается яснее и быстрее работает (благодаря возможной оптимизации через хэши или таблицы переходов в некоторых реализациях).

4.3. Избегание побочных эффектов в условиях

Условия должны быть чистыми — без изменения состояния. Например, избегайте:

if (x = get_value) && x > 0  # присваивание внутри условия

Лучше:

x = get_value
if x && x > 0

Это повышает предсказуемость и облегчает отладку.

4.4. nil-безопасность через &. (safe navigation operator)

Начиная с Ruby 2.3, оператор &. позволяет безопасно вызывать методы у потенциально nil-объектов:

user&.profile&.name
# эквивалентно:
user && user.profile && user.profile.name

Уменьшает многословность защитных проверок.


5. Приоритеты операторов в Ruby

Понимание приоритета операторов критично для корректного чтения и написания выражений без избыточных скобок. В Ruby приоритеты строго фиксированы и не зависят от контекста.

Ниже приведён список операторов в порядке убывания приоритета (сверху — самый высокий):

ПриоритетОператорыПримечания
1::Разрешение области видимости (например, Math::PI)
2[], []=Доступ и присваивание по индексу/ключу
3**Возведение в степень (правоассоциативный: 2 ** 3 ** 2 = 2 ** 9)
4унарные +, -, !, ~+5, -x, !condition, ~mask
5*, /, %Арифметические: умножение, деление, модуль
6+, -Сложение и вычитание
7<<, >>Побитовые сдвиги
8&Побитовое И
9|, ^Побитовое ИЛИ, исключающее ИЛИ
10<, <=, >, >=, <=>, ==, ===, !=, =~, !~Операторы сравнения и сопоставления
11&&Логическое И (высокий приоритет)
12||Логическое ИЛИ (высокий приоритет)
13.., ...Диапазоны
14? :Тернарный условный оператор
15=, +=, -=, и др. составные присваиванияВсе формы присваивания (низкий приоритет)
16notЛогическое НЕ (низкоприоритетное)
17and, orЛогические И/ИЛИ (низкоприоритетные)
18defined?Проверка определённости
19if, unless, while, until (в постфиксной форме)Модификаторы — самый низкий приоритет

Важные следствия:

  • a = b && c интерпретируется как a = (b && c), а не (a = b) && c.
    Это означает: сначала вычисляется b && c, затем результат присваивается a.
  • a = b and c(a = b) and c. Здесь присваивание происходит первым, затем результат присваивания (значение b) участвует в and c.
  • x = y ? a : bx = (y ? a : b) — тернарный оператор выше присваивания.

Рекомендация: избегайте смешивания and/or с присваиванием без скобок. При сомнениях — используйте &&/|| или явные скобки.


6. Сравнительный анализ: Ruby vs Python vs JavaScript

АспектRubyPythonJavaScript
Операторы как методыДа: a + ba.+(b)Нет: + — встроенная операция (но __add__ для перегрузки)Нет: + — встроенный, перегрузка невозможна
Логическое «и»&& (высокий приоритет), and (низкий)and (единственный, приоритет ниже !=, выше not)&& (единственный)
Условное присваивание`=(«nil-coalescing assignment»),&&=`
Нулевое слияниеНет оператора ??, но `xyчасто используется (результат —y, если x` ложно)
Циклы по коллекцииeach, map, select (итераторы + блоки)for item in list:, map(), filter()for-of, forEach, map, filter
Условие в whileЛюбое значение: только false/nil — ложныеЛюбое значение: 0, "", [], None — ложныеЛюбое значение: 0, "", null, undefined, NaN, false — falsy
Оператор «космический корабль»<=> (встроен, возвращает -1, 0, 1, nil)Нет (но cmp() в Python 2, или (a > b) - (a < b))Нет (но можно реализовать)
Диапазоны1..5, 1...5 — объекты Rangerange(1, 6) — генератор/последовательностьНет (только через массивы или for (let i = 1; i <= 5; i++))
Безопасная навигация&. (начиная с 2.3)Нет (но getattr(obj, 'attr', default))?. (ES2020)

Ключевое отличие Ruby: единообразие через объектность. Поскольку всё — объект, поведение операторов можно расширять. Например, можно определить Vector#* для скалярного умножения, и v * 2 будет работать интуитивно. В Python перегрузка тоже возможна, но через специальные методы (__mul__); в JavaScript — невозможно без изменения прототипов (что небезопасно).


7. Типичные ошибки и диагностика

7.1. Перепутывание and/or с &&/||

Ошибка:

user = find_user && user.active?  # OK: сначала find_user, затем active?
user = find_user and user.active?  # ОПАСНО: эквивалентно (user = find_user) and user.active?

Если find_user возвращает nil, вторая часть не выполнится — но если возвращает объект, user.active? вызовется и проигнорируется (результат не присваивается никуда).

Диагностика: RuboCop выдаёт предупреждение Style/AndOr при использовании and/or вне управляющих конструкций.

7.2. Изменение коллекции внутри each

Ошибка:

arr = [1, 2, 3, 4]
arr.each { |x| arr.delete(x) if x.even? }
# Результат: [1, 3] — но итерация пропустит элементы!

При удалении элементов индексы смещаются, и each «перепрыгивает» через следующий элемент.

Решение: использовать delete_if, reject!, select — методы, предназначенные для фильтрации:

arr.delete_if(&:even?)

7.3. Использование for и утечка переменной

Ошибка:

name = "глобальное"
for name in ["Алиса", "Боб"]
# ...
end
puts name # → "Боб"

Переменная name перезаписана.

Решение: заменить на each:

["Алиса", "Боб"].each { |name| ... }  # name локальна в блоке

7.4. x ||= y и ложные, но значимые значения

Ошибка:

count ||= 10

Если count == 0, выражение присвоит 10, хотя 0 — валидное значение.

Решение: проверять явно на nil:

count = 10 if count.nil?
# или
count = defined?(count) && count.nil? ? 10 : count # избыточно
# лучший вариант — инициализировать при объявлении

7.5. Сравнение == и equal?

Ошибка:

a = "hello"
b = "hello"
a == b # → true
a.equal?(b) # → false (разные объекты)

equal? проверяет тождественность объектов (один и тот же object_id), а не равенство содержимого.

Когда использовать:

  • == — для семантического равенства,
  • equal? — для проверки, ссылается ли переменная на тот же самый объект (редко, в основном в метапрограммировании или тестах).

8. Практикум (необязательное приложение)

Для закрепления материала рекомендуются следующие упражнения. Каждое можно реализовать в виде отдельной задачи в обучающем курсе.

Упражнение 1. Реализация fizz_buzz тремя способами

  1. С while
  2. С loop + break
  3. С (1..n).each
    Сравните читаемость, длину кода, риск ошибок.

Упражнение 2. Безопасное вычисление цепочки методов

Дан объект:
user = { profile: { settings: { theme: "dark" } } }
Напишите выражение, возвращающее theme или "default", если любой уровень nil.
Решения:

  • С вложенными &&
  • С &. (safe navigation)
  • С dig (встроенный метод Hash#dig)

Упражнение 3. Перегрузка оператора

Создайте класс Vector2D с координатами x, y. Реализуйте:

  • + — покомпонентное сложение,
  • * — умножение на скаляр (если аргумент — число) и скалярное произведение (если — Vector2D),
  • <=> для сортировки по длине вектора.

Упражнение 4. Ленивая фильтрация бесконечной последовательности

Сгенерируйте первые 10 простых чисел, начиная с 2, с использованием lazy.